[id].vue 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627
  1. <template>
  2. <div v-if="workflow" class="flex h-screen">
  3. <div
  4. v-if="state.showSidebar"
  5. class="w-80 bg-white dark:bg-gray-800 py-6 relative border-l border-gray-100 dark:border-gray-700 dark:border-opacity-50 flex flex-col"
  6. >
  7. <workflow-edit-block
  8. v-if="editState.editing"
  9. v-model:autocomplete="autocompleteState.cache"
  10. :data="editState.blockData"
  11. :data-changed="autocompleteState.dataChanged"
  12. :workflow="workflow"
  13. :editor="editor"
  14. @update="updateBlockData"
  15. @close="(editState.editing = false), (editState.blockData = {})"
  16. />
  17. <workflow-details-card
  18. v-else
  19. :workflow="workflow"
  20. @update="updateWorkflow"
  21. />
  22. </div>
  23. <div class="flex-1 relative overflow-auto">
  24. <div
  25. class="absolute w-full flex items-center z-10 left-0 p-4 top-0 pointer-events-none"
  26. >
  27. <ui-tabs
  28. v-model="state.activeTab"
  29. class="border-none px-2 rounded-lg h-full space-x-1 bg-white dark:bg-gray-800 pointer-events-auto"
  30. >
  31. <button
  32. v-tooltip="
  33. `${t('workflow.toggleSidebar')} (${
  34. shortcut['editor:toggle-sidebar'].readable
  35. })`
  36. "
  37. style="margin-right: 6px"
  38. @click="toggleSidebar"
  39. >
  40. <v-remixicon
  41. :name="state.showSidebar ? 'riSideBarFill' : 'riSideBarLine'"
  42. />
  43. </button>
  44. <ui-tab value="editor">{{ t('common.editor') }}</ui-tab>
  45. <ui-tab value="logs" class="flex items-center">
  46. {{ t('common.log', 2) }}
  47. <span
  48. v-if="workflowStore.states.length > 0"
  49. class="ml-2 p-1 text-center inline-block text-xs rounded-full bg-accent text-white dark:text-black"
  50. style="min-width: 25px"
  51. >
  52. {{ workflowStore.states.length }}
  53. </span>
  54. </ui-tab>
  55. </ui-tabs>
  56. <div class="flex-grow pointer-events-none" />
  57. <editor-local-actions
  58. :editor="editor"
  59. :workflow="workflow"
  60. :is-data-changed="state.dataChanged"
  61. @update="onActionUpdated"
  62. @modal="(modalState.name = $event), (modalState.show = true)"
  63. />
  64. </div>
  65. <ui-tab-panels
  66. v-model="state.activeTab"
  67. class="overflow-hidden h-full w-full"
  68. @drop="onDropInEditor"
  69. @dragend="clearHighlightedElements"
  70. @dragover.prevent="onDragoverEditor"
  71. >
  72. <ui-tab-panel cache value="editor" class="w-full">
  73. <workflow-editor
  74. v-if="state.workflowConverted"
  75. :id="route.params.id"
  76. :data="workflow.drawflow"
  77. class="h-screen"
  78. @init="onEditorInit"
  79. @edit="initEditBlock"
  80. @update:node="state.dataChanged = true"
  81. @delete:node="state.dataChanged = true"
  82. />
  83. <editor-local-ctx-menu
  84. v-if="editor"
  85. :editor="editor"
  86. @copy="copySelectedElements"
  87. @paste="pasteCopiedElements"
  88. @duplicate="duplicateElements"
  89. />
  90. </ui-tab-panel>
  91. <ui-tab-panel value="logs" class="mt-24">
  92. <editor-logs
  93. :workflow-id="route.params.id"
  94. :workflow-states="workflowStore.states"
  95. />
  96. </ui-tab-panel>
  97. </ui-tab-panels>
  98. </div>
  99. </div>
  100. <ui-modal
  101. v-model="modalState.show"
  102. :content-class="activeWorkflowModal?.width || 'max-w-xl'"
  103. v-bind="activeWorkflowModal.attrs || {}"
  104. >
  105. <template v-if="activeWorkflowModal.title" #header>
  106. {{ activeWorkflowModal.title }}
  107. <a
  108. v-if="activeWorkflowModal.docs"
  109. :title="t('common.docs')"
  110. :href="activeWorkflowModal.docs"
  111. target="_blank"
  112. class="inline-block align-middle"
  113. >
  114. <v-remixicon name="riInformationLine" size="20" />
  115. </a>
  116. </template>
  117. <component
  118. :is="activeWorkflowModal.component"
  119. v-bind="{ workflow }"
  120. v-on="activeWorkflowModal?.events || {}"
  121. @update="updateWorkflow"
  122. @close="modalState.show = false"
  123. />
  124. </ui-modal>
  125. </template>
  126. <script setup>
  127. import {
  128. provide,
  129. reactive,
  130. computed,
  131. onMounted,
  132. shallowRef,
  133. onBeforeUnmount,
  134. } from 'vue';
  135. import { useI18n } from 'vue-i18n';
  136. import { useRoute, useRouter, onBeforeRouteLeave } from 'vue-router';
  137. import { customAlphabet } from 'nanoid';
  138. import defu from 'defu';
  139. import { useStore } from '@/stores/main';
  140. import { useUserStore } from '@/stores/user';
  141. import { useWorkflowStore } from '@/stores/workflow';
  142. import { useShortcut } from '@/composable/shortcut';
  143. import { tasks } from '@/utils/shared';
  144. import { debounce, parseJSON, throttle } from '@/utils/helper';
  145. import { fetchApi } from '@/utils/api';
  146. import browser from 'webextension-polyfill';
  147. import EditorUtils from '@/utils/EditorUtils';
  148. import convertWorkflowData from '@/utils/convertWorkflowData';
  149. import WorkflowShare from '@/components/newtab/workflow/WorkflowShare.vue';
  150. import WorkflowEditor from '@/components/newtab/workflow/WorkflowEditor.vue';
  151. import WorkflowSettings from '@/components/newtab/workflow/WorkflowSettings.vue';
  152. import WorkflowEditBlock from '@/components/newtab/workflow/WorkflowEditBlock.vue';
  153. import WorkflowDataTable from '@/components/newtab/workflow/WorkflowDataTable.vue';
  154. import WorkflowGlobalData from '@/components/newtab/workflow/WorkflowGlobalData.vue';
  155. import WorkflowDetailsCard from '@/components/newtab/workflow/WorkflowDetailsCard.vue';
  156. import EditorLogs from '@/components/newtab/workflow/editor/EditorLogs.vue';
  157. import EditorLocalCtxMenu from '@/components/newtab/workflow/editor/EditorLocalCtxMenu.vue';
  158. import EditorLocalActions from '@/components/newtab/workflow/editor/EditorLocalActions.vue';
  159. const nanoid = customAlphabet('1234567890abcdef', 7);
  160. const { t } = useI18n();
  161. const store = useStore();
  162. const route = useRoute();
  163. const router = useRouter();
  164. const userStore = useUserStore();
  165. const workflowStore = useWorkflowStore();
  166. /* eslint-disable-next-line */
  167. const shortcut = useShortcut('editor:toggle-sidebar', toggleSidebar);
  168. const editor = shallowRef(null);
  169. const state = reactive({
  170. showSidebar: true,
  171. dataChanged: false,
  172. workflowConverted: false,
  173. activeTab: route.query.tab || 'editor',
  174. });
  175. const modalState = reactive({
  176. name: '',
  177. show: false,
  178. });
  179. const editState = reactive({
  180. blockData: {},
  181. editing: false,
  182. });
  183. const autocompleteState = reactive({
  184. cache: new Map(),
  185. dataChanged: false,
  186. });
  187. const workflowPayload = {
  188. data: {},
  189. isUpdating: false,
  190. };
  191. const workflowModals = {
  192. table: {
  193. icon: 'riKey2Line',
  194. width: 'max-w-2xl',
  195. component: WorkflowDataTable,
  196. title: t('workflow.table.title'),
  197. docs: 'https://docs.automa.site/api-reference/table.html',
  198. },
  199. 'workflow-share': {
  200. icon: 'riShareLine',
  201. component: WorkflowShare,
  202. attrs: {
  203. blur: true,
  204. persist: true,
  205. customContent: true,
  206. },
  207. events: {
  208. close() {
  209. modalState.show = false;
  210. modalState.name = '';
  211. },
  212. publish() {
  213. modalState.show = false;
  214. modalState.name = '';
  215. },
  216. },
  217. },
  218. 'global-data': {
  219. width: 'max-w-2xl',
  220. icon: 'riDatabase2Line',
  221. component: WorkflowGlobalData,
  222. title: t('common.globalData'),
  223. docs: 'https://docs.automa.site/api-reference/global-data.html',
  224. },
  225. settings: {
  226. width: 'max-w-2xl',
  227. icon: 'riSettings3Line',
  228. component: WorkflowSettings,
  229. title: t('common.settings'),
  230. attrs: {
  231. customContent: true,
  232. },
  233. events: {
  234. close() {
  235. modalState.show = false;
  236. modalState.name = '';
  237. },
  238. },
  239. },
  240. };
  241. const workflow = computed(() => workflowStore.getById(route.params.id));
  242. const activeWorkflowModal = computed(
  243. () => workflowModals[modalState.name] || {}
  244. );
  245. provide('workflow', {
  246. editState,
  247. data: workflow,
  248. });
  249. const updateBlockData = debounce((data) => {
  250. const node = editor.value.getNode.value(editState.blockData.blockId);
  251. const dataCopy = JSON.parse(JSON.stringify(data));
  252. if (editState.blockData.itemId) {
  253. const itemIndex = node.data.blocks.findIndex(
  254. ({ itemId }) => itemId === editState.blockData.itemId
  255. );
  256. if (itemIndex === -1) return;
  257. node.data.blocks[itemIndex].data = dataCopy;
  258. } else {
  259. node.data = dataCopy;
  260. }
  261. editState.blockData.data = data;
  262. state.dataChanged = true;
  263. }, 250);
  264. const updateHostedWorkflow = throttle(async () => {
  265. if (!userStore.user || workflowPayload.isUpdating) return;
  266. const isHosted = userStore.hostedWorkflows[route.params.id];
  267. const isBackup = userStore.backupIds.includes(route.params.id);
  268. const workflowExist = workflowStore.getById(route.params.id);
  269. if (
  270. (!isBackup && !isHosted) ||
  271. (workflowExist && Object.keys(workflowPayload.data).length === 0)
  272. )
  273. return;
  274. workflowPayload.isUpdating = true;
  275. const delKeys = [
  276. 'id',
  277. 'pass',
  278. 'logs',
  279. 'trigger',
  280. 'createdAt',
  281. 'isDisabled',
  282. 'isProtected',
  283. ];
  284. delKeys.forEach((key) => {
  285. delete workflowPayload.data[key];
  286. });
  287. try {
  288. if (typeof workflowPayload.data.drawflow === 'string') {
  289. workflowPayload.data.drawflow = parseJSON(
  290. workflowPayload.data.drawflow,
  291. workflowPayload.data.drawflow
  292. );
  293. }
  294. const response = await fetchApi(`/me/workflows/${route.params.id}`, {
  295. method: 'PUT',
  296. keepalive: true,
  297. body: JSON.stringify({
  298. workflow: workflowPayload.data,
  299. }),
  300. });
  301. if (!response.ok) throw new Error(response.message);
  302. if (isBackup) {
  303. const result = await response.json();
  304. if (result.updatedAt) {
  305. await browser.storage.local.set({ lastBackup: result.updatedAt });
  306. }
  307. }
  308. workflowPayload.data = {};
  309. workflowPayload.isUpdating = false;
  310. } catch (error) {
  311. console.error(error);
  312. workflowPayload.isUpdating = false;
  313. }
  314. }, 5000);
  315. const onNodesChange = debounce((changes) => {
  316. changes.forEach(({ type, id }) => {
  317. if (type === 'remove') {
  318. if (editState.blockData.blockId === id) {
  319. editState.editing = false;
  320. editState.blockData = {};
  321. }
  322. }
  323. });
  324. }, 250);
  325. function toggleSidebar() {
  326. state.showSidebar = !state.showSidebar;
  327. localStorage.setItem('workflow:sidebar', state.showSidebar);
  328. }
  329. function initEditBlock(data) {
  330. const { editComponent, data: blockDefData } = tasks[data.id];
  331. const blockData = defu(data.data, blockDefData);
  332. editState.editing = true;
  333. editState.blockData = { ...data, editComponent, data: blockData };
  334. }
  335. function updateWorkflow(data) {
  336. workflowStore.update({
  337. data,
  338. id: route.params.id,
  339. });
  340. workflowPayload.data = { ...workflowPayload.data, ...data };
  341. updateHostedWorkflow();
  342. }
  343. function onActionUpdated({ data, changedIndicator }) {
  344. state.dataChanged = changedIndicator;
  345. workflowPayload.data = { ...workflowPayload.data, ...data };
  346. updateHostedWorkflow();
  347. }
  348. function onEditorInit(instance) {
  349. editor.value = instance;
  350. instance.onEdgesChange((changes) => {
  351. changes.forEach(({ type }) => {
  352. if (state.dataChanged) return;
  353. state.dataChanged = type !== 'select';
  354. });
  355. });
  356. instance.onEdgeDoubleClick(({ edge }) => {
  357. instance.removeEdges([edge]);
  358. });
  359. instance.onNodesChange(onNodesChange);
  360. }
  361. function clearHighlightedElements() {
  362. const elements = document.querySelectorAll(
  363. '.dropable-area__node, .dropable-area__handle'
  364. );
  365. elements.forEach((element) => {
  366. element.classList.remove('dropable-area__node');
  367. element.classList.remove('dropable-area__handle');
  368. });
  369. }
  370. function toggleHighlightElement({ target, elClass, classes }) {
  371. const targetEl = target.closest(elClass);
  372. if (targetEl) {
  373. targetEl.classList.add(classes);
  374. } else {
  375. const elements = document.querySelectorAll(`.${classes}`);
  376. elements.forEach((element) => {
  377. element.classList.remove(classes);
  378. });
  379. }
  380. }
  381. function onDragoverEditor({ target }) {
  382. toggleHighlightElement({
  383. target,
  384. elClass: '.vue-flow__handle.source',
  385. classes: 'dropable-area__handle',
  386. });
  387. if (!target.closest('.vue-flow__handle')) {
  388. toggleHighlightElement({
  389. target,
  390. elClass: '.vue-flow__node:not(.vue-flow__node-BlockGroup)',
  391. classes: 'dropable-area__node',
  392. });
  393. }
  394. }
  395. function onDropInEditor({ dataTransfer, clientX, clientY, target }) {
  396. const block = parseJSON(dataTransfer.getData('block'), null);
  397. if (!block) return;
  398. clearHighlightedElements();
  399. const nodeEl = EditorUtils.isNode(target);
  400. if (nodeEl) {
  401. EditorUtils.replaceNode(editor.value, { block, target: nodeEl });
  402. return;
  403. }
  404. const isTriggerExists =
  405. block.id === 'trigger' &&
  406. editor.value.getNodes.value.some((node) => node.label === 'trigger');
  407. if (isTriggerExists) return;
  408. const position = editor.value.project({ x: clientX - 360, y: clientY - 18 });
  409. const newNode = {
  410. position,
  411. id: nanoid(),
  412. label: block.id,
  413. data: block.data,
  414. type: block.component,
  415. };
  416. editor.value.addNodes([newNode]);
  417. const edgeEl = EditorUtils.isEdge(target);
  418. const handleEl = EditorUtils.isHandle(target);
  419. if (handleEl) {
  420. EditorUtils.appendNode(editor.value, {
  421. target: handleEl,
  422. nodeId: newNode.id,
  423. });
  424. } else if (edgeEl) {
  425. EditorUtils.insertBetweenNode(editor.value, {
  426. target: edgeEl,
  427. nodeId: newNode.id,
  428. outputs: block.outputs,
  429. });
  430. }
  431. if (block.fromGroup) {
  432. setTimeout(() => {
  433. const blockEl = document.querySelector(`[data-id="${newNode.id}"]`);
  434. blockEl?.setAttribute('group-item-id', block.itemId);
  435. }, 200);
  436. }
  437. state.dataChanged = true;
  438. }
  439. function copyElements(nodes, edges, initialPos) {
  440. const newIds = new Map();
  441. let firstNodePos = null;
  442. const newNodes = nodes.map(({ id, label, position, data, type }, index) => {
  443. const newNodeId = nanoid();
  444. const nodePos = {
  445. z: position.z || 0,
  446. y: position.y + 50,
  447. x: position.x + 50,
  448. };
  449. newIds.set(id, newNodeId);
  450. if (initialPos) {
  451. if (index === 0) {
  452. firstNodePos = {
  453. x: nodePos.x,
  454. y: nodePos.y,
  455. };
  456. initialPos = editor.value.project({
  457. y: initialPos.clientY,
  458. x: initialPos.clientX - 360,
  459. });
  460. Object.assign(nodePos, initialPos);
  461. } else {
  462. const xDistance = nodePos.x - firstNodePos.x;
  463. const yDistance = nodePos.y - firstNodePos.y;
  464. nodePos.x = initialPos.x + xDistance;
  465. nodePos.y = initialPos.y + yDistance;
  466. }
  467. }
  468. return {
  469. type,
  470. data,
  471. label,
  472. id: newNodeId,
  473. selected: true,
  474. position: nodePos,
  475. };
  476. });
  477. const newEdges = edges.reduce(
  478. (acc, { target, targetHandle, source, sourceHandle }) => {
  479. const targetId = newIds.get(target);
  480. const sourceId = newIds.get(source);
  481. if (!targetId || !sourceId) return acc;
  482. acc.push({
  483. selected: true,
  484. target: targetId,
  485. source: sourceId,
  486. id: `edge-${nanoid()}`,
  487. targetHandle: targetHandle.replace(target, targetId),
  488. sourceHandle: sourceHandle.replace(source, sourceId),
  489. });
  490. return acc;
  491. },
  492. []
  493. );
  494. return {
  495. nodes: newNodes,
  496. edges: newEdges,
  497. };
  498. }
  499. function duplicateElements({ nodes, edges }) {
  500. editor.value.removeSelectedNodes(editor.value.getSelectedNodes.value);
  501. editor.value.removeSelectedEdges(editor.value.getSelectedEdges.value);
  502. const { edges: newEdges, nodes: newNodes } = copyElements(nodes, edges);
  503. editor.value.addNodes(newNodes);
  504. editor.value.addEdges(newEdges);
  505. }
  506. function copySelectedElements(data = {}) {
  507. store.copiedEls.nodes = data.nodes || editor.value.getSelectedNodes.value;
  508. store.copiedEls.edges = data.edges || editor.value.getSelectedEdges.value;
  509. }
  510. function pasteCopiedElements(position) {
  511. editor.value.removeSelectedNodes(editor.value.getSelectedNodes.value);
  512. editor.value.removeSelectedEdges(editor.value.getSelectedEdges.value);
  513. const { nodes, edges } = copyElements(
  514. store.copiedEls.nodes,
  515. store.copiedEls.edges,
  516. position
  517. );
  518. editor.value.addNodes(nodes);
  519. editor.value.addEdges(edges);
  520. }
  521. function onKeydown({ ctrlKey, metaKey, key }) {
  522. const command = (keyName) => (ctrlKey || metaKey) && keyName === key;
  523. if (command('c')) {
  524. copySelectedElements();
  525. } else if (command('v')) {
  526. pasteCopiedElements();
  527. }
  528. }
  529. /* eslint-disable consistent-return */
  530. onBeforeRouteLeave(() => {
  531. updateHostedWorkflow();
  532. if (!state.dataChanged) return;
  533. const confirm = window.confirm(t('message.notSaved'));
  534. if (!confirm) return false;
  535. });
  536. onMounted(() => {
  537. if (!workflow.value) {
  538. router.replace('/');
  539. return null;
  540. }
  541. state.showSidebar =
  542. JSON.parse(localStorage.getItem('workflow:sidebar')) ?? true;
  543. const convertedData = convertWorkflowData(workflow.value);
  544. updateWorkflow({ drawflow: convertedData.drawflow });
  545. state.workflowConverted = true;
  546. window.onbeforeunload = () => {
  547. updateHostedWorkflow();
  548. if (state.dataChanged) {
  549. return t('message.notSaved');
  550. }
  551. };
  552. window.addEventListener('keydown', onKeydown);
  553. });
  554. onBeforeUnmount(() => {
  555. window.onbeforeunload = null;
  556. window.removeEventListener('keydown', onKeydown);
  557. });
  558. </script>
  559. <style>
  560. .vue-flow,
  561. .editor-tab {
  562. width: 100%;
  563. height: 100%;
  564. }
  565. .vue-flow__node {
  566. @apply rounded-lg;
  567. }
  568. .dropable-area__node,
  569. .dropable-area__handle {
  570. @apply ring-4;
  571. }
  572. </style>